Skip to content

Add grails.mongodb.buildIndexes and buildIndexesAsync for MongoDB index creation on startup - #16208

Open
codeconsole wants to merge 7 commits into
apache:8.0.xfrom
codeconsole:feature/mongodb-skip-index-build-8.0.x
Open

Add grails.mongodb.buildIndexes and buildIndexesAsync for MongoDB index creation on startup#16208
codeconsole wants to merge 7 commits into
apache:8.0.xfrom
codeconsole:feature/mongodb-skip-index-build-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Two settings for the MongoDB index build that runs when the datastore starts, plus the reporting to make either decision an informed one.

All of these are runtime configuration, set in grails-app/conf/application.yml (or application.groovy) — nothing in build.gradle.

grails.mongodb.buildIndexes

grails:
    mongodb:
        buildIndexes: false

GORM creates and reconciles every index declared in a mapping block each time the datastore starts, with no way to switch it off. That is unwanted when deploying against live data whose indexes are managed separately, by a DBA or a migration step: the deployment builds indexes against production collections and reconciles the index set the running application depends on.

With the setting off, no createIndex or collMod is issued for any domain class and the indexes on the server are left exactly as they are. Persistence and querying are unaffected and keep using whichever indexes exist. It also covers domain classes registered after startup, and resolves per connection like every other connection setting, so index building can be switched off globally and left on for one connection:

grails:
    mongodb:
        buildIndexes: false
        connections:
            reporting:
                url: mongodb://localhost/reporting
                buildIndexes: true

A collection that has never started up with index building enabled will have no declared indexes at all, so this is for environments where the indexes are already in place or are applied by other means. It governs only what GORM derives from mapping blocks; an explicit createIndex in application code is unaffected.

grails.mongodb.buildIndexesAsync

grails:
    mongodb:
        buildIndexesAsync: true

MongoDB answers a createIndex only once the index has been built, so by default startup waits for every declared index in turn. Measured against MongoDB 7.0 with 200k documents: 172ms to create one index on the calling thread, 1.2ms to re-issue the same declaration. background: true has been ignored since MongoDB 4.2 and costs the same.

With this enabled the startup build runs on one daemon thread per connection, named gorm-mongo-index-build-<connection>, and startup continues without waiting. Indexes are still built one at a time rather than all at once against the server.

Worth planning for:

  • A query issued before its index has been built is served without it — correctly, but unindexed. A unique index likewise constrains nothing until the build finishes.
  • Startup no longer waits, so a failed index build can no longer fail startup. It is logged at error level and the application runs without that index. The default synchronous build still propagates the exception.
  • It applies to the startup build only. A domain class registered afterwards is indexed on the thread registering it, which keeps the tenant context that path can depend on.

Index build reporting

A successful build previously logged nothing at all — only conflicts, TTL updates and failures — so there was no way to tell how much of startup went on indexes, and with the async build no signal that it had finished. It now logs one summary:

Index build for database [myDb] finished in 412ms: 2 created, 5 already present, from 3 domain class(es)

The created/already-present split is what makes the elapsed time interpretable: a restart that changed no mappings reports everything as already present and costs milliseconds, so a summary reporting indexes created is the one that accounts for a slow start. A build with failures is logged at WARN and reports how many. Each index also logs its own elapsed time at DEBUG under org.grails.datastore.mapping.core, which is how to find the single slow index behind a slow summary.

createIndexes reports numIndexesBefore / numIndexesAfter, but the driver's createIndex helper discards the response and returns only the index name. Rather than hand-roll the command — the IndexOptions to index-specification mapping is not worth reimplementing — the build lists the indexes a collection already has. That listing is lazy, so an entity declaring no indexes costs no round trip, and it replaces the listing the conflict path used to make for itself. If it cannot be read, the build still runs and the summary falls back to reporting how many declarations were applied.

Settings were ignored when the application supplies its own MongoClient

Found while testing the above. The constructors taking a MongoClient (or a MongoClientSettings.Builder) built the default connection source from a bare MongoConnectionSourceSettings, so every grails.mongodb setting describing how the datastore behaves — stateless, transactional, engine, flush mode, decimalType, and now buildIndexes — was silently left at its default. Only databaseName survived, because it is set explicitly from the mapping context.

That path is not obscure: Spring Boot's MongoDB auto-configuration contributes a MongoClient bean and MongoDbGormAutoConfiguration hands that client to GORM whenever one is present, so a Spring Boot application configuring GORM through grails.mongodb was being ignored. The settings are now bound from the configuration on that path too; the connection details in them go unused, as the client arrives already connected.

This is a behaviour change for anyone who had been running on defaults without knowing it — a stateless = true or transactional = true that was previously discarded now takes effect.

Notes

  • Documented in grails-data-mongodb/docs under Querying Indexing and Advanced Configuration, with the 8.0 release notes updated. Those pages showed configuration only in the application.groovy DSL, so they now show the application.yml form as well, and note that these settings are read by name — they are not relaxed-bound like Spring Boot's own properties, so a kebab-case spelling such as build-indexes is ignored and leaves the default in place.
  • The mongo core tests now run against logback rather than the no-op SLF4J binding, as grails-data-neo4j/core already does, so a test can assert on what was logged. The root logger is pinned to WARN by a logback-test.xml, and the build output is unchanged.

…rtup

GORM created and reconciled every index declared in a domain class mapping
block each time the datastore started, with no way to switch it off. That is
unwanted when deploying against live data whose indexes are managed
separately: the deployment builds indexes against production collections and
reconciles the index set the running application depends on.

Setting grails.mongodb.buildIndexes = false leaves the server's indexes
exactly as they are - no createIndex or collMod command is issued for any
domain class, including for domain classes registered after startup.
Persistence and querying are unaffected and continue to use whichever
indexes already exist.

The setting is resolved per connection like every other connection setting,
so index building can be switched off globally and left on for an individual
connection.
The constructors that take a MongoClient (or a MongoClientSettings.Builder)
built the default connection source from a bare MongoConnectionSourceSettings,
so every setting under grails.mongodb that describes how the datastore behaves
- stateless, transactional, engine, flush mode, decimalType, buildIndexes -
was silently left at its default. Only databaseName survived, because it is set
explicitly from the mapping context.

That path is not obscure: Spring Boot's MongoDB auto-configuration contributes
a MongoClient bean, and MongoDbGormAutoConfiguration hands that client to GORM
whenever it is present, so a Spring Boot application configuring GORM through
grails.mongodb was being ignored.

The settings are now bound from the configuration on this path too. The
connection details in them go unused, as the client is supplied already
connected.
… thread

MongoDB answers a createIndex command only once the index has been built, and
the datastore builds every declared index from its constructor, so an
application waits at startup for all of them in turn. On an empty collection
that is instant; on a large existing collection it is minutes of a deployment
spent waiting.

Setting grails.mongodb.buildIndexesAsync = true hands the startup build to a
single daemon thread per connection, named gorm-mongo-index-build-<connection>,
and returns immediately. Indexes are still built one at a time rather than all
at once against the server.

Two consequences are documented rather than hidden: a query issued before its
index exists is served without it (a unique index likewise constrains nothing
until the build completes), and a build failure can no longer fail startup, so
it is logged at error level instead. The setting covers the startup build only
- a domain class registered later is still indexed on the registering thread,
which keeps the tenant context that path can depend on.
A successful index build said nothing at all: only conflicts, TTL updates and
failures were logged, so there was no way to tell how much of startup went on
building indexes, and with buildIndexesAsync no signal that the background
build had finished.

The build now logs one summary when it completes:

    Applied 7 index declaration(s) from 3 domain class(es) to database [myDb] in 412ms

The count is declarations applied rather than indexes built, because
createIndex is idempotent and the server accepts one that already exists
without doing any work - claiming otherwise would be wrong on every restart
after the first. A build with failures is logged at warn and reports how many.
Each index also logs its own elapsed time at debug, which is how to find the
one slow index behind a slow summary.

The tests now run against logback rather than the no-op SLF4J binding, as the
sibling data modules do, so that a test can assert on what was logged; the
root logger is quietened to WARN so this does not add noise to the build.
The previous summary counted declarations applied and said, in as many words,
that it could not tell whether any of them did work. That was wrong about the
server: createIndexes answers with numIndexesBefore, numIndexesAfter and a
"note: all indexes already exist", and it is the driver's createIndex helper
that discards the response and returns only the index name.

Rather than hand-roll the command to read that response - the mapping from
IndexOptions to an index specification is not worth reimplementing - the build
lists the indexes a collection already has, once per collection, and reports:

    Index build for database [myDb] finished in 412ms: 2 created, 5 already present, from 3 domain class(es)

That split is what makes the elapsed time mean something. Measured against
MongoDB 7.0 with 200k documents: creating an index took 172ms on the calling
thread, re-issuing the same declaration 1.2ms. A restart that changed no
mappings reports everything as already present, so a summary reporting created
indexes is the one that accounts for a slow start.

The listing is lazy, so an entity declaring no indexes costs no round trip, and
it replaces the listing the conflict path used to make for itself. If it cannot
be read the build still runs and the summary falls back to reporting how many
declarations were applied.
@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.24752% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.1510%. Comparing base (e40cb27) to head (6fbb305).

Files with missing lines Patch % Lines
...grails/datastore/mapping/mongo/MongoDatastore.java 75.2475% 19 Missing and 6 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16208        +/-   ##
==================================================
+ Coverage     54.1238%   54.1510%   +0.0272%     
- Complexity      20307      20328        +21     
==================================================
  Files            2107       2107                
  Lines          101144     101228        +84     
  Branches        17921      17935        +14     
==================================================
+ Hits            54743      54816        +73     
- Misses          38595      38602         +7     
- Partials         7806       7810         +4     
Files with missing lines Coverage Δ
...tions/AbstractMongoConnectionSourceSettings.groovy 70.0000% <ø> (ø)
...grails/datastore/mapping/mongo/MongoDatastore.java 70.8333% <75.2475%> (+2.6709%) ⬆️

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…n.groovy

The configuration examples were written only in the application.groovy DSL,
which reads as though the Groovy form were the only one; application.yml is
what a generated Grails application actually ships with.

Both forms are now shown, and the settings are noted as being read by name:
they are not relaxed-bound the way Spring Boot's own properties are, so a
kebab-case spelling is ignored and silently leaves the default in place.
@testlens-app

testlens-app Bot commented Aug 24, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 6fbb305
▶️ Tests: 66204 executed
⚪️ Checks: 89/89 completed


Learn more about TestLens at testlens.app/docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant